Skip to content
View Article Network

How to use Vue 3 with ASP.NET Razor

Versions Used

Introduction

Vue 3 provides both "Composition API" and "Options API" styles. This article continues to use the "Options API" because the official Vue documentation mentions that many benefits of the "Composition API" are only realized in large-scale projects. For lightweight frontend implementations that reference JS files, the "Options API" is still recommended. It is definitely not because I do not understand the "Composition API" syntax and do not want to learn it. The following is quoted from the official documentation.

Which to Choose?

For production use:

Go with Options API if you are not using build tools, or plan to use Vue primarily in low-complexity scenarios, such as progressive enhancement.

Go with Composition API + Single-File Components if you plan to build full applications with Vue.

Will Options API be deprecated?#

No, we do not have any plan to do so. Options API is an integral part of Vue and the reason many developers love it. We also realize that many of the benefits of Composition API only manifest in larger-scale projects, and Options API remains a solid choice for many low-to-medium-complexity scenarios.

Architecture Overview

The architecture is adjusted based on the content of the article "How to use Vue with ASP.NET Razor". This article only provides the new code and will not repeat the previous explanations.

Code

_Layout.cshtml

Here, we will create the Vue instance and configure the use of other packages. Note the following points:

  • The name of the Vue component must be the same as the Tag name of the TagHelper, for example, VForm corresponds to the <v-form></v-form> generated by VeeValidateFormTagHelper.
  • VeeValidateFormTagHelper will generate the attribute :initial-errors="initialErrors()", which is used to call initialErrors to initialize error messages.
  • Please replace {Component Name} with the actual DLL assembly name; this will dynamically generate a style file specific to the assembly, with the content being the same as _Layout.cshtml.css.
html
<!DOCTYPE html>
<html lang="zh-Hant-TW">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/{Component Name}.styles.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    @RenderSection("Head", required: false)
</head>
<body>
    <div id="vueApp" class="container" v-cloak>
        @RenderBody()
    </div>
    <script src="~/lib/vue/vue.global.prod.js"></script>
    <script src="~/lib/popper/umd/popper.min.js"></script>
    <script src="~/lib/bootstrap/js/bootstrap.min.js"></script>
    <script src="~/lib/vee-validate/vee-validate.prod.min.js"></script>
    <script src="~/lib/vee-validate/rules/dist/vee-validate-rules.min.js"></script>
    <script src="~/lib/vee-validate/i18n/dist/vee-validate-i18n.min.js"></script>
    <script src="~/lib/axios/axios.min.js"></script>
    <script src="~/js/vee-validate-rules-extension.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    <script>
        Object.keys(VeeValidateRules).forEach(rule => {
            if (rule !== 'default') {
                VeeValidate.defineRule(rule, VeeValidateRules[rule]);
            }
        });

        VeeValidateI18n.loadLocaleFromURL('@Url.Content("~/lib/vee-validate/i18n/dist/locale/zh_TW.json")');

        VeeValidate.configure({
            generateMessage: VeeValidateI18n.localize('zh_TW'),
        });

        let mixins = [];
    </script>

    @await RenderSectionAsync("Scripts", required: false)

    <script>
        let vueApp = Vue.createApp({
            components: {
                VForm: VeeValidate.Form,
                VField: VeeValidate.Field,
                VMessage: VeeValidate.ErrorMessage,
            },
            methods: {
                initialErrors() {
                    let initialErrors = {};

                    @foreach (var pair in ViewContext.ViewData.ModelState.Where(x => x.Value!.Errors.Any()))
                    {
                        <text>
                            initialErrors['@pair.Key'] = '@Html.Raw(pair.Value.Errors.First().ErrorMessage.Replace("'", "\\'"))';
                        </text>
                    }
                    return initialErrors
                }
            }
        })
        .use(VeeValidate);

        for (let i in mixins) {
            vueApp.mixin(mixins[i]);
        }

        vueApp.mount('#vueApp');
    </script>
</body>
</html>

vee-validate-rules-extension.js

This is to add validations that exist in jquery.validate.js but are missing in vee-validate-rules.js.

javascript
(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined'
        ? factory(exports)
        : typeof define === 'function' && define.amd
            ? define(['exports'], factory)
            : (global = typeof globalThis !== 'undefined'
                ? globalThis
                : global || self, factory(global.VeeValidateRules));
})(this, (function (exports) {
    'use strict';

    function isEmpty(value) {
        if (value === null || value === undefined || value === '') {
            return true;
        }
        if (Array.isArray(value) && value.length === 0) {
            return true;
        }
        return false;
    }

    function validateCreditCardRule(value) {
        let sum = 0;
        let digit;
        let tmpNum;
        let shouldDouble;

        let sanitized = value.replace(/[- ]+/g, '');

        for (let i = sanitized.length - 1; i >= 0; i--) {
            digit = sanitized.substring(i, i + 1);
            tmpNum = parseInt(digit, 10);

            if (shouldDouble) {
                tmpNum *= 2;

                if (tmpNum >= 10) {
                    sum += tmpNum % 10 + 1;
                } else {
                    sum += tmpNum;
                }
            } else {
                sum += tmpNum;
            }

            shouldDouble = !shouldDouble;
        }

        return !!(sum % 10 === 0 ? sanitized : false);
    }

    const creditCardValidator = (value) => {
        if (isEmpty(value)) {
            return true;
        }
        const re = /^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|(222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11}|6[27][0-9]{14})$/;
        if (Array.isArray(value)) {
            return value.every(val => re.test(String(val)) && validateCreditCardRule(String(val)));
        }
        return re.test(String(value)) && validateCreditCardRule(String(val));
    };

    const urlValidator = (value) => {
        if (isEmpty(value)) {
            return true;
        }
        const re = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i;
        if (Array.isArray(value)) {
            return value.every(val => re.test(String(val)));
        }
        return re.test(String(value));
    }

    /* eslint-disable camelcase */
    exports["default"].credit_card = creditCardValidator;
    exports["default"].url = urlValidator;

    exports.credit_card = creditCardValidator;
    exports.url = urlValidator;

    Object.defineProperty(exports, '__esModule', { value: true });

}));

site.css

Hide the original template before the component is compiled.

css
[v-cloak] {
    display: none;
}

site.js

Add RequestVerificationToken to the headers for ajax requests to perform ValidateAntiForgeryToken validation.

javascript
axios.interceptors.request.use(
    config => {
        let token = document.querySelector('input[name="__RequestVerificationToken"]');
        if (token !== null) {
            config.headers = {
                RequestVerificationToken: token.value
            }
        }
        return config;
    }
);

VeeValidateFormTagHelper

Used to generate <v-form></v-form>.

csharp
    [HtmlTargetElement("v-form", TagStructure = TagStructure.NormalOrSelfClosing)]
    public class VeeValidateFormTagHelper : FormTagHelper {
        private const string VueSlotAttributeName = "v-slot";

        public VeeValidateFormTagHelper(IHtmlGenerator generator) : base(generator) { }

        public override void Process(TagHelperContext context, TagHelperOutput output) {
            output.Attributes.Add(":initial-errors", "initialErrors()");

            if (!context.AllAttributes.ContainsName(VueSlotAttributeName)) {
                output.Attributes.Add(VueSlotAttributeName, "{ isSubmitting }");
            }

            base.Process(context, output);
        }
    }

VeeValidateInputTagHelper

Used to generate <v-field></v-field>, and output DataAnnotations like Required as rules="required" for vee-validate-rules.js to parse. The reason for choosing vee-validate.js as the frontend validation package is that it supports parsing attributes for frontend validation.

csharp
[HtmlTargetElement("v-field", Attributes = ForAttributeName, TagStructure = TagStructure.NormalOrSelfClosing)]
public class VeeValidateInputTagHelper : InputTagHelper {
    private const string ForAttributeName = "asp-for";
    private const string RulesAttributeName = "rules";
    private const string VueModelAttributeName = "v-model";

    public VeeValidateInputTagHelper(IHtmlGenerator generator) : base(generator) { }

    public override void Process(TagHelperContext context, TagHelperOutput output) {
        if (context is null) {
            throw new ArgumentNullException(nameof(context));
        }

        if (output is null) {
            throw new ArgumentNullException(nameof(output));
        }

        if (For is null) {
            return;
        }

        if (!context.AllAttributes.ContainsName(RulesAttributeName)) {
            string? rules = GetRules();
            if (rules != null) {
                output.Attributes.Add(RulesAttributeName, GetRules());
            }
        }

        base.Process(output);

        string[] excludeTypes = new string[] { "radio", "checkbox" };

        if (context.AllAttributes.ContainsName(VueModelAttributeName) && !excludeTypes.Contains(context.AllAttributes["type"].Value)) {
            output.Attributes.RemoveAt(output.Attributes.IndexOfName("value"));
        }
    }

    private string? GetRules() {
        List<string> items = new List<string>();

        if (For is not null) {
            foreach (var validationAttribute in For.Metadata.ValidatorMetadata) {
                switch (validationAttribute) {
                    case CompareAttribute attr:
                        // HACK Not sure if it can be captured correctly
                        string[] forNameParts = For.Name.Split('.');
                        forNameParts[^1] = attr.OtherProperty;
                        items.Add($"confirmed:@{string.Join(".", forNameParts)}");
                        break;
                    case CreditCardAttribute _:
                        items.Add("credit_card");
                        break;
                    case EmailAddressAttribute _:
                        items.Add("email");
                        break;
                    case FileExtensionsAttribute attr:
                        items.Add($"ext:{attr.Extensions}");
                        break;
                    case StringLengthAttribute attr:
                        if (attr.MaximumLength > 0) {
                            items.Add($"max:{attr.MaximumLength}");
                        }
                        if (attr.MinimumLength > 0) {
                            items.Add($"min:{attr.MinimumLength}");
                        }
                        break;
                    case MaxLengthAttribute attr:
                        if (attr.Length > 0) {
                            items.Add($"max:{attr.Length}");
                        }
                        break;
                    case MinLengthAttribute attr:
                        if (attr.Length > 0) {
                            items.Add($"min:{attr.Length}");
                        }
                        break;
                    case RangeAttribute attr:
                        items.Add($"between:{attr.Minimum},{attr.Maximum}");
                        break;
                    case RequiredAttribute _:
                        items.Add("required");
                        break;
                    case UrlAttribute _:
                        items.Add("url");
                        break;
                }
            }
        }

        if (items.Any()) {
            return $"{string.Join("|", items)}";
        }

        return null;
    }
}

WARNING

vee-validate also uses <v-field></v-field> for generating <select></select>, but I haven't tested that yet.

VeeValidateMessageTagHelper

text-danger is used to match Bootstrap styles; please adjust it as needed.

csharp
[HtmlTargetElement("v-message", Attributes = ForAttributeName, TagStructure = TagStructure.NormalOrSelfClosing)]
public class VeeValidateMessageTagHelper : TagHelper {
    private const string ForAttributeName = "asp-validation-for";

    [HtmlAttributeName(ForAttributeName)]
    public ModelExpression? For { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output) {
        if (context is null) {
            throw new ArgumentNullException(nameof(context));
        }

        if (output is null) {
            throw new ArgumentNullException(nameof(output));
        }

        if (For is null) {
            return;
        }

        output.Attributes.Add("name", For.Name);
        output.AddClass("text-danger", HtmlEncoder.Default);
    }
}

VueInputTagHelper

The primary generation of <input /> is still handled by the native InputTagHelper. The purpose here is simply to remove the value attribute when v-model is set, to avoid warnings from Vue.

csharp
[HtmlTargetElement("input", Attributes = ForAttributeName, TagStructure = TagStructure.WithoutEndTag)]
public class VueInputTagHelper : TagHelper {
    private const string ForAttributeName = "asp-for";
    private const string VueModelAttributeName = "v-model";

    public override void Process(TagHelperContext context, TagHelperOutput output) {
        string[] excludeTypes = new string[] { "radio", "checkbox" };

        if (context.AllAttributes.ContainsName(VueModelAttributeName) && !excludeTypes.Contains(context.AllAttributes["type"].Value)) {
            output.Attributes.RemoveAt(output.Attributes.IndexOfName("value"));
        }
    }
}

_ViewImports.cshtml

Please replace {ProjectNamespace} with your project's Namespace and {TagHelperNamespace} with the Namespace of your custom TagHelpers. Note that since custom TagHelpers depend on native TagHelpers, the order cannot be swapped.

csharp
@namespace {ProjectNamespace}.Pages

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, {TagHelperNamespace}

Page.cshtml

  • You only need to use <v-form></v-form> and <v-field></v-field> if you need frontend field validation; otherwise, standard <form></form> and <input /> are sufficient.
  • isSubmitting is defined within VeeValidateFormTagHelper to disable the button after submission, preventing duplicate clicks.
html
<v-form method="post" asp-page="./Test">
    <input asp-for="Test.TestRequired" type="text"></v-field>
    <v-message asp-validation-for="Test.TestRequired"></v-message>
    <button type="submit" :disabled="isSubmitting">Submit</button>
    <button type="button" v-on:click="count++">{{ count }}</button>
</v-form>

@section Scripts {
    <script>
        let pageMixin = {
            data: function () {
                return {
                    count: 0
                };
            }
        }
        mixins.push(pageMixin);
    </script>
}

Conclusion

Actually, I really didn't want to use Vue Components, but since I couldn't find any other suitable frontend validation input field packages, I had to make do. This architecture is currently under testing, and if there are other issues, I will update the content.

WARNING

This article was written on 2023/01/30. Recently, while testing the project on 2024/04/06, I discovered that the <v-form></v-form> generated by VeeValidateFormTagHelper causes asp-page-handler to not function correctly. Furthermore, error messages cannot correctly display field names set by attributes like DisplayName.

I currently have no plans to resolve these issues and have decided to abandon this architecture. In the future, when developing Web applications, I might continue to choose Vue 2 with vee-validate 2, or consider using Vue 3 after ASP.NET Core drops its dependency on jQuery for frontend validation. Alternatively, considering that Vue 2 has reached end-of-life and I am a bit tired of incompatibilities caused by frontend framework or package updates, I might just embrace ASP.NET Core Blazor =.=a.

Change Log

  • 2023-01-30 Initial document creation.
  • 2024-04-07 Added notes on unresolved issues in the article's architecture.